今天開始就由小弟本人帶大家使用 trpc
摟~,為了讓大家快速感受 trpc
的魅力, 這邊會推薦別人整理好的 T3 stack
做介紹,T3 stack
算是筆者認為把 trpc
架構用的非常好的框架,而目前 trpc
生態中主流會是以 T3 stack
為主,還沒用過的朋友可以去看看 T3 app~
但讀者也不用擔心看不懂,這邊筆者會一一介紹T3 stack
架構內容~
跟使用 vite 起專案一樣,只要打以下的 cli 指令就會幫你創建你需要的 template,讀者只要一一根據提示選擇需要內容就OK~
npm create t3-app@latest
T3 stack 很貼心的部分是有提供非常多的專案工具選項, nextAuth、prisma 、 tailwindcss 以及非常重要的主題 trpc !!!,可以根據你的需求選擇你要的工具,這邊為了教學就全部都給他加進去XD,畢竟小孩才做選擇我全部都要哈哈。
簡單介紹一下選擇的套件~
介紹:他是一個做第三方登入的功能,搭配 next api route 實現 OAuth 2.0 驗證,保存第三方 user info 到你的 session 中。
網址: https://next-auth.js.org/
介紹:一個 type safe 的 ORM 框架,大部分有在用 trpc 的使用者都會去搭配使用。
網址: https://www.prisma.io/
介紹:trpc 的 client 端是封包 react query 的內容,所以 call api 方式會跟 react query一樣。
網址: https://tanstack.com/query/v5/
介紹:第一天有介紹喔~。
網址: https://trpc.io/
那各位讀者也不用擔心沒用過,這些日後都會慢慢介紹的~
建好專案後整體結構如下:
prisma : 整個 db
會用到的 schema都在這邊
,同時所有 db migrate
紀錄都會在這邊出現,這邊得 migrate
資料呀,會根據你使用的 db
種類而有差異, prisma
整合非常多的 db
從SQl
到 noSQl
都有例如 Postgresql
甚至是 mongodb
等等,那 t3 stack
預設會是使用 postgresql
,所以這邊的 migrations
內容都是 postgresql
的 SQL
指令。
src/pages/api/auth/[...nextauth] : 這邊是 nextauth
做第三方登入需要回傳驗證結果的 api route
,細節部分日後再一一解說 code
。
src/pages/api/auth/[trpc] : 這邊就是轉裡所有 trpc api
的入口, 還記得昨天提到 trpc
他是一個 client
跟 server
的設計模式吧,這邊就是 trpc
的 server
端入口,統一管理所有定義的 route
、req contet
、global error handler
等等。
server/auth : 所有關於 nextauth
的 option
設定
server/db : 這邊就是你會用到的 db instance
,因為t3 stack是用 prisma 這邊就是放 prisma 得 instance。
server/db : 這邊就是你會用到的 db instance
,因為t3 stack
是用 prisma
這邊就是放 prisma
得 instance
。
utils/api : 所有 api
的呼叫都在這邊。
env.mjs : 你可以在這邊定義環境變數,t3 app
很貼心的是他是透過 zod
去幫你驗證你的 env
是否有缺漏、內容是否有誤,所有 env的引用都會在這邊。
├── README.md
├── docker-compose.yml
├── next-env.d.ts
├── next.config.mjs
├── package-lock.json
├── package.json
├── postcss.config.cjs
├── prettier.config.cjs
├── prisma
│ ├── migrations
│ │ ├── 20230307161717_dev
│ │ │ └── migration.sql
│ │ ├── 20230307163349_
│ │ │ └── migration.sql
│ │ ├── 20230313151256_add_response_model
│ │ │ └── migration.sql
│ │ └── migration_lock.toml
│ └── schema.prisma
├── public
│ └── favicon.ico
├── src
│ ├── env.mjs
│ ├── pages
│ │ ├── _app.tsx
│ │ ├── api
│ │ │ ├── auth
│ │ │ │ └── [...nextauth].ts
│ │ │ └── trpc
│ │ │ └── [trpc].ts
│ │ ├── index.tsx
│ │ └── poll
│ │ └── [pollId].tsx
│ ├── server
│ │ ├── api
│ │ │ ├── root.ts
│ │ │ ├── routers
│ │ │ │ ├── example.ts
│ │ │ │ └── poll.ts
│ │ │ └── trpc.ts
│ │ ├── auth.ts
│ │ └── db.ts
│ ├── styles
│ │ └── globals.css
│ └── utils
│ └── api.ts
├── tailwind.config.cjs
└── tsconfig.json
相信大家看到這邊的 env 使用肯定會非常疑惑,放心我第一次看也是很不懂,這邊就一步一步帶大家使用。
這邊可以根據你 env 的內容變化去制定 schema rule。
DATABASE_URL: z.string().url()
: 必須是 https或是 http開頭的 env
如 https://sample。
NODE_ENV: z.enum(["development", "test", "production"])
: 指定 NODE_ENV
有什麼環境選項。
NEXTAUTH_SECRET
: 根據你的 NODE_ENV
dynamic 你的 schema rule。
NEXTAUTH_URL
: preprocess
他是一個可以幫你轉換變數成你要的 value
,他有兩個參數,第一個是轉換的 callback function
,第二個則是 schema rule
,整個流程會是,假設你部署到 vercel
那你的 env 中就會自動有 vercel
提供的 predefined
的 env
VERCEL_URL
跟 VERCEL
,如果你是在 vercel
部署那就是替換 NEXTAUTH_URL
的 value 成 VERCEL_URL
的 value,反之則不變。
//.env
NEXTAUTH_URL="https://vercel.com/some_path"
NEXTAUTH_URL="http://localhost:3000"
const hasDeployToVercel = z.preprocess(
(str) => process.env.VERCEL_URL ?? str,
process.env.VERCEL ? z.string().min(1) : z.string().url(),
)
console.log(hasDeployToVercel.parse(process.env.NEXTAUTH_URL)) // "https://vercel.com/some_path"
//.env
NEXTAUTH_URL="http://localhost:3000"
const hasDeployToVercel = z.preprocess(
(str) => process.env.VERCEL_URL ?? str,
process.env.VERCEL ? z.string().min(1) : z.string().url(),
)
console.log(hasDeployToVercel.parse(process.env.NEXTAUTH_URL)) // "http://localhost:3000"
DISCORD_CLIENT_ID 跟 DISCORD_CLIENT_SECRET
: string type。
const server = z.object({
DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(["development", "test", "production"]),
NEXTAUTH_SECRET:
process.env.NODE_ENV === "production"
? z.string().min(1)
: z.string().min(1).optional(),
NEXTAUTH_URL: z.preprocess(
// This makes Vercel deployments not fail if you don't set NEXTAUTH_URL
// Since NextAuth.js automatically uses the VERCEL_URL if present.
(str) => process.env.VERCEL_URL ?? str,
// VERCEL_URL doesn't include `https` so it cant be validated as a URL
process.env.VERCEL ? z.string().min(1) : z.string().url(),
),
// Add `.min(1) on ID and SECRET if you want to make sure they're not empty
DISCORD_CLIENT_ID: z.string(),
DISCORD_CLIENT_SECRET: z.string(),
});
在 next 中你除了可以定義 server env
外,也可以定義 client
端內容,兩者可以合而為一統一驗證,同時別忘記引入你所有的 env
喔這邊用 processEnv
代替。
const server = z.object({
DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(["development", "test", "production"]),
NEXTAUTH_SECRET:
process.env.NODE_ENV === "production"
? z.string().min(1)
: z.string().min(1).optional(),
NEXTAUTH_URL: z.preprocess(
(str) => process.env.VERCEL_URL ?? str,
process.env.VERCEL ? z.string().min(1) : z.string().url(),
),
DISCORD_CLIENT_ID: z.string(),
DISCORD_CLIENT_SECRET: z.string(),
});
const client = z.object({
// NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
});
const merged = server.merge(client);
const processEnv = {
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET,
};
所有 zod schema 的驗證都是呼叫前幾部定義好的 schema 並呼叫 parse function,但這邊採用 safeParse 原因是,safeParse 並不會 throw error出來,他只回傳一個 result,這個 result 包含 data 跟 status,相反 parse 如果驗證錯誤會直接 throw error 反之 return value,這邊會用 safeParse 是因為想客製化 error message 如果想改成 parse 也可以看大家的需求~
const isServer = typeof window === "undefined";
const parsed = /** @type {MergedSafeParseReturn} */ (
isServer
? merged.safeParse(processEnv) // on server we can validate all env vars
: client.safeParse(processEnv) // on client we can only validate the ones that are exposed
);
if (parsed.success === false) {
console.error(
"❌ Invalid environment variables:",
parsed.error.flatten().fieldErrors,
);
throw new Error("Invalid environment variables");
}
簡單 demo 看出 safeParse 跟 parse 差別
import { z } from "zod";
const schema = z.object({
name: z.string()
})
let dataSuccess = {
name: 'danny'
}
let dataWrong = {
name: 10
}
const dataSuccessResult = schema.parse(dataSuccess)
// { name: 'danny' }
const dataWrongResult = schema.parse(dataWrong)
// error - ZodError: [
// {
// "code": "invalid_type",
// "expected": "string",
// "received": "number",
// "path": [
// "name"
// ],
// "message": "Expected string, received number"
// }
// ]
const dataSuccessResult1 = schema.safeParse(dataSuccess)
// { success: true, data: { name: 'danny' } }
const dataWrongResult2 = schema.safeParse(dataWrong)
// {
// success: false,
// error: [Getter],
// _error: ZodError: [
// {
// "code": "invalid_type",
// "expected": "string",
// "received": "number",
// "path": [
// "name"
// ],
// "message": "Expected string, received number"
// }
// ]
// }
jsDoc
並不是只會在 js
取寫,很多人可能以為 jsDoc
只是 typescript
出來前的替帶品,但其實兩者是可以一起使用的,好處就是 typescript
可以幫你做 type check
,jsDoc
則是可以幫你的 code base
做詳細補充,但其實 jsDoc
也可以寫 type 喔~來看一下範例。
// 先定義 type ,用法就是 /** @typedef {your_type} your_type_name*/
/** @typedef {z.infer<typeof merged>} MergedOutput */
// 指定變數 type ,用法 /** @type {your_type_name}*/ , your_type_name 除了 typedef 定義的 name,以外也可以是 string 等原始 type 種類
let env = /** @type {MergedOutput} */(process.env)
這樣只要 hover env 變數就知道他有什麼 env 拉~
SKIP_ENV_VALIDATION
env
決定要不要做 env validate
isServer
決定 safeParse
的 schema
用哪個parsed.success throw error
proxy
方式檢查 env
引用,讓使用 env
錯誤時有 log
來源if (!!process.env.SKIP_ENV_VALIDATION == false) {
const isServer = typeof window === "undefined";
const parsed = /** @type {MergedSafeParseReturn} */ (
isServer
? merged.safeParse(processEnv) // on server we can validate all env vars
: client.safeParse(processEnv) // on client we can only validate the ones that are exposed
);
if (parsed.success === false) {
console.error(
"❌ Invalid environment variables:",
parsed.error.flatten().fieldErrors,
);
throw new Error("Invalid environment variables");
}
env = new Proxy(parsed.data, {
get(target, prop) {
if (typeof prop !== "string") return undefined;
// Throw a descriptive error if a server-side env var is accessed on the client
// Otherwise it would just be returning `undefined` and be annoying to debug
if (!isServer && !prop.startsWith("NEXT_PUBLIC_"))
throw new Error(
process.env.NODE_ENV === "production"
? "❌ Attempted to access a server-side environment variable on the client"
: `❌ Attempted to access server-side environment variable '${prop}' on the client`,
);
return target[/** @type {keyof typeof target} */ (prop)];
},
});
}
最後附上完整 code
// src/env.mjs
import { z } from "zod";
const server = z.object({
DATABASE_URL: z.string().url(),
NODE_ENV: z.enum(["development", "test", "production"]),
NEXTAUTH_SECRET:
process.env.NODE_ENV === "production"
? z.string().min(1)
: z.string().min(1).optional(),
NEXTAUTH_URL: z.preprocess(
(str) => process.env.VERCEL_URL ?? str,
process.env.VERCEL ? z.string().min(1) : z.string().url(),
),
DISCORD_CLIENT_ID: z.string(),
DISCORD_CLIENT_SECRET: z.string(),
});
const client = z.object({
// NEXT_PUBLIC_CLIENTVAR: z.string().min(1),
});
const processEnv = {
DATABASE_URL: process.env.DATABASE_URL,
NODE_ENV: process.env.NODE_ENV,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
DISCORD_CLIENT_ID: process.env.DISCORD_CLIENT_ID,
DISCORD_CLIENT_SECRET: process.env.DISCORD_CLIENT_SECRET,
};
const merged = server.merge(client);
/** @typedef {z.input<typeof merged>} MergedInput */
/** @typedef {z.infer<typeof merged>} MergedOutput */
/** @typedef {z.SafeParseReturnType<MergedInput, MergedOutput>} MergedSafeParseReturn */
let env = /** @type {MergedOutput} */ (process.env);
if (!!process.env.SKIP_ENV_VALIDATION == false) {
const isServer = typeof window === "undefined";
const parsed = /** @type {MergedSafeParseReturn} */ (
isServer
? merged.safeParse(processEnv) // on server we can validate all env vars
: client.safeParse(processEnv) // on client we can only validate the ones that are exposed
);
if (parsed.success === false) {
console.error(
"❌ Invalid environment variables:",
parsed.error.flatten().fieldErrors,
);
throw new Error("Invalid environment variables");
}
env = new Proxy(parsed.data, {
get(target, prop) {
if (typeof prop !== "string") return undefined;
// Throw a descriptive error if a server-side env var is accessed on the client
// Otherwise it would just be returning `undefined` and be annoying to debug
if (!isServer && !prop.startsWith("NEXT_PUBLIC_"))
throw new Error(
process.env.NODE_ENV === "production"
? "❌ Attempted to access a server-side environment variable on the client"
: `❌ Attempted to access server-side environment variable '${prop}' on the client`,
);
return target[/** @type {keyof typeof target} */ (prop)];
},
});
}
export { env };
好了今天內容到這邊,明天會繼續陪大家研究其他資料夾部分,讀者如果有更多架構疑問可以下方留言一起討論喔~我們明天見
https://trpc.io/
https://tanstack.com/query/v5/
https://www.prisma.io/
https://next-auth.js.org/
✅ 前端社群 :
https://lihi3.cc/kBe0Y